Skip to content

feat: configurable auto-zoom settings with UI sliders#1663

Open
namearth5005 wants to merge 6 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-config
Open

feat: configurable auto-zoom settings with UI sliders#1663
namearth5005 wants to merge 6 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-config

Conversation

@namearth5005
Copy link

@namearth5005 namearth5005 commented Mar 18, 2026

Summary

Promotes auto-zoom from experimental toggle to configurable feature (refs #1646):

  • New AutoZoomConfig struct — extracts all 11 hardcoded constants from zoom segment generation into a serializable, backward-compatible config with #[serde(default)]
  • Wired through the full stackGeneralSettingsStore → Tauri IPC command → generate_zoom_segments_from_clicks_impl
  • 3 settings sliders in Experimental Settings (shown when auto-zoom is enabled): Zoom Amount (1-4x), Sensitivity (High/Medium/Low), Smoothing (0.2-2.0s)
  • Zero behavior change for existing recordings — all defaults match the previous hardcoded values exactly

Files changed

File Change
crates/project/src/configuration.rs Added AutoZoomConfig struct with 11 fields
apps/desktop/src-tauri/src/general_settings.rs Added auto_zoom_config to GeneralSettingsStore
apps/desktop/src-tauri/src/recording.rs Parameterized segment generation to accept config
apps/desktop/src-tauri/src/lib.rs Updated IPC command to pass settings config
apps/desktop/src/routes/.../experimental.tsx Added SettingSlider component and 3 config sliders

Backward compatibility

Every new field uses #[serde(default)] with values matching the old hardcoded constants:

Old Constant Default
AUTO_ZOOM_AMOUNT 1.5
CLICK_GROUP_TIME_THRESHOLD_SECS 2.5
CLICK_GROUP_SPATIAL_THRESHOLD 0.15
CLICK_PRE_PADDING 0.4
CLICK_POST_PADDING 1.8
MERGE_GAP_THRESHOLD 0.8
MIN_SEGMENT_DURATION 1.0

This is the first of 5 incremental PRs to promote auto-zoom to production quality.

Test plan

  • Existing 3 Rust tests pass with AutoZoomConfig::default() — verified locally (cargo check -p cap-project passes; tests require ffmpeg for full cap-desktop build)
  • Open Settings → Experimental → Enable "Auto zoom on clicks" → sliders appear
  • Adjust sliders → values persist across settings close/reopen
  • Record a Studio Mode session with auto-zoom → zoom segments generated with configured amount
  • Existing recordings without config render identically (serde defaults)

Greptile Summary

This PR promotes auto-zoom from an on/off experimental toggle to a fully configurable feature by extracting 11 hardcoded constants into a new AutoZoomConfig struct that is persisted in GeneralSettingsStore and surfaced via three UI sliders (Zoom Amount, Sensitivity, Smoothing). The Rust side is clean and backward-compatible — all defaults match the previous constants and #[serde(default)] is used throughout. The TypeScript UI is well-structured with a reusable SettingSlider component.

Notable issues:

  • Sensitivity slider controls only half the detection conditionmovementEventDistanceThreshold (per-frame) is left at its default 0.02 while only movementWindowDistanceThreshold is exposed. Since the significance check uses ||, the per-frame branch can still fire at the "Low" end of the slider, making the sensitivity control weaker than users would expect.
  • Hardcoded TS defaults duplicate Rust defaults — the inline fallback object in Inner (lines 68–80) mirrors AutoZoomConfig::default() exactly, creating a hidden maintenance dependency that could silently drift.
  • Minor: non-idiomatic error handlingunwrap_or(None) on the settings fetch in lib.rs is functional but less readable than the .ok().flatten() pattern already used elsewhere in the codebase.

Confidence Score: 3/5

  • Safe to merge for an experimental feature branch, but the Sensitivity slider has a functional gap that could confuse users expecting full low-sensitivity behaviour.
  • The Rust plumbing and serde compatibility are solid. The main concern is the UX correctness of the Sensitivity slider — lowering it to "Low" still allows frequent zoom triggers via the unconfigured per-frame distance threshold. This won't cause crashes or data loss but it is a user-visible behaviour mismatch that should ideally be addressed before the feature is promoted to non-experimental. Score also reflects the duplicate default values and the minor idiomatic issue.
  • Pay close attention to apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx — the Sensitivity slider and the hardcoded default config object both warrant a second look before this exits experimental.

Important Files Changed

Filename Overview
apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx Adds SettingSlider component and 3 config sliders. Key issue: the "Sensitivity" slider only adjusts movementWindowDistanceThreshold, leaving movementEventDistanceThreshold at its default, which weakens the low-sensitivity end of the range. Also hardcodes default config values that duplicate Rust defaults.
crates/project/src/configuration.rs Adds AutoZoomConfig struct with 11 fields, all with serde defaults matching previous hardcoded constants. Clean implementation with proper derive macros and camelCase serialization for TypeScript interop.
apps/desktop/src-tauri/src/general_settings.rs Adds auto_zoom_config field to GeneralSettingsStore with #[serde(default)], correctly wired through Default impl. Straightforward and backward-compatible.
apps/desktop/src-tauri/src/recording.rs Parameterizes generate_zoom_segments_from_clicks_impl, generate_zoom_segments_from_clicks, and generate_zoom_segments_for_project to accept &AutoZoomConfig. Hardcoded constants correctly extracted into config locals. STOP_PADDING_SECONDS, MOVEMENT_WINDOW_SECONDS, SHAKE_FILTER_THRESHOLD, and SHAKE_FILTER_WINDOW_MS intentionally remain as internal constants. Tests updated to pass AutoZoomConfig::default().
apps/desktop/src-tauri/src/lib.rs generate_zoom_segments_from_clicks command now accepts AppHandle to load settings and pass auto_zoom_config. Uses slightly non-idiomatic unwrap_or(None) pattern (minor style issue) to fetch settings.

Sequence Diagram

sequenceDiagram
    participant UI as experimental.tsx
    participant Store as generalSettingsStore
    participant IPC as Tauri IPC
    participant GSS as GeneralSettingsStore
    participant REC as recording.rs
    participant CFG as AutoZoomConfig

    UI->>Store: generalSettingsStore.set({ autoZoomConfig })
    Store->>IPC: persists to disk (JSON with camelCase)

    Note over UI,IPC: Editor — "Generate zoom from clicks"
    UI->>IPC: generate_zoom_segments_from_clicks()
    IPC->>GSS: GeneralSettingsStore::get(&app)
    GSS-->>IPC: Ok(Some(settings)) or fallback to Default
    IPC->>REC: generate_zoom_segments_for_project(meta, recordings, &settings.auto_zoom_config)
    REC->>CFG: destructure AutoZoomConfig fields
    CFG-->>REC: zoom_amount, thresholds, paddings…
    REC-->>IPC: Vec<ZoomSegment>
    IPC-->>UI: zoom segments applied to timeline

    Note over REC,CFG: Recording completion (studio mode)
    REC->>GSS: GeneralSettingsStore::get(&app)
    GSS-->>REC: settings with auto_zoom_config
    REC->>REC: generate_zoom_segments_from_clicks(recording, recordings, &settings.auto_zoom_config)
    REC-->>REC: project_config with zoom_segments
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx, line 58-82 (link)

    P2 Hardcoded defaults duplicated from Rust

    The autoZoomConfig default object on lines 68–80 exactly mirrors the values in AutoZoomConfig::default() in Rust. This creates a silent maintenance hazard: if any default changes on the Rust side (e.g. tuning click_post_padding in a later PR), the TypeScript fallback silently stays stale and the UI will show incorrect defaults when initialStore is null.

    Since initialStore comes from generalSettingsStore.get() and the backend already applies #[serde(default)], the TypeScript fallback is only exercised before the first async load. Consider trusting the backend for defaults and initialising the store without an inline autoZoomConfig override, or at minimum adding a comment linking the two so reviewers know to keep them in sync.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
    Line: 58-82
    
    Comment:
    **Hardcoded defaults duplicated from Rust**
    
    The `autoZoomConfig` default object on lines 68–80 exactly mirrors the values in `AutoZoomConfig::default()` in Rust. This creates a silent maintenance hazard: if any default changes on the Rust side (e.g. tuning `click_post_padding` in a later PR), the TypeScript fallback silently stays stale and the UI will show incorrect defaults when `initialStore` is `null`.
    
    Since `initialStore` comes from `generalSettingsStore.get()` and the backend already applies `#[serde(default)]`, the TypeScript fallback is only exercised before the first async load. Consider trusting the backend for defaults and initialising the store without an inline `autoZoomConfig` override, or at minimum adding a comment linking the two so reviewers know to keep them in sync.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 170-187

Comment:
**"Sensitivity" slider only controls half the detection condition**

The "Sensitivity" slider adjusts `movementWindowDistanceThreshold` (cumulative window distance) but leaves `movementEventDistanceThreshold` (per-frame distance) fixed at `0.02`. In `generate_zoom_segments_from_clicks_impl`, movement detection is an **OR** condition:

```rust
let significant_movement = distance >= movement_event_distance_threshold   // fixed: 0.02
    || window_distance >= movement_window_distance_threshold;              // slider-controlled
```

This means even at "Low" sensitivity (`movementWindowDistanceThreshold = 0.2`), any single frame where the cursor travels ≥ 2% of normalised screen width still triggers a zoom. In practice the per-frame branch can dominate, making the "Low" end of the slider much weaker than users expect.

To make the slider fully effective, both thresholds should scale together. You could either:
- Expose `movementEventDistanceThreshold` as part of the sensitivity curve, or
- Derive `movementEventDistanceThreshold` proportionally from the `movementWindowDistanceThreshold` value rather than keeping it at its hardcoded default.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 58-82

Comment:
**Hardcoded defaults duplicated from Rust**

The `autoZoomConfig` default object on lines 68–80 exactly mirrors the values in `AutoZoomConfig::default()` in Rust. This creates a silent maintenance hazard: if any default changes on the Rust side (e.g. tuning `click_post_padding` in a later PR), the TypeScript fallback silently stays stale and the UI will show incorrect defaults when `initialStore` is `null`.

Since `initialStore` comes from `generalSettingsStore.get()` and the backend already applies `#[serde(default)]`, the TypeScript fallback is only exercised before the first async load. Consider trusting the backend for defaults and initialising the store without an inline `autoZoomConfig` override, or at minimum adding a comment linking the two so reviewers know to keep them in sync.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 2119-2121

Comment:
**Idiomatic error handling with `ok().flatten()`**

`unwrap_or(None)` on a `Result<Option<T>>` is non-idiomatic and silently discards the error. The codebase already uses a cleaner pattern elsewhere (e.g. `recording.rs:598` uses `.ok().flatten()`):

```suggestion
    let settings = GeneralSettingsStore::get(&app)
        .ok()
        .flatten()
        .unwrap_or_default();
```

This makes the intent explicit — convert `Err` to `None`, then fall back to defaults — and is consistent with the rest of the file.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "style: apply cargo f..."

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Comment on lines +170 to +187
<SettingSlider
label="Sensitivity"
value={
settings.autoZoomConfig?.movementWindowDistanceThreshold ??
0.08
}
onChange={(v) =>
handleConfigChange("movementWindowDistanceThreshold", v)
}
min={0.02}
max={0.2}
step={0.01}
format={(v) => {
if (v <= 0.05) return "High";
if (v <= 0.12) return "Medium";
return "Low";
}}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Sensitivity" slider only controls half the detection condition

The "Sensitivity" slider adjusts movementWindowDistanceThreshold (cumulative window distance) but leaves movementEventDistanceThreshold (per-frame distance) fixed at 0.02. In generate_zoom_segments_from_clicks_impl, movement detection is an OR condition:

let significant_movement = distance >= movement_event_distance_threshold   // fixed: 0.02
    || window_distance >= movement_window_distance_threshold;              // slider-controlled

This means even at "Low" sensitivity (movementWindowDistanceThreshold = 0.2), any single frame where the cursor travels ≥ 2% of normalised screen width still triggers a zoom. In practice the per-frame branch can dominate, making the "Low" end of the slider much weaker than users expect.

To make the slider fully effective, both thresholds should scale together. You could either:

  • Expose movementEventDistanceThreshold as part of the sensitivity curve, or
  • Derive movementEventDistanceThreshold proportionally from the movementWindowDistanceThreshold value rather than keeping it at its hardcoded default.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 170-187

Comment:
**"Sensitivity" slider only controls half the detection condition**

The "Sensitivity" slider adjusts `movementWindowDistanceThreshold` (cumulative window distance) but leaves `movementEventDistanceThreshold` (per-frame distance) fixed at `0.02`. In `generate_zoom_segments_from_clicks_impl`, movement detection is an **OR** condition:

```rust
let significant_movement = distance >= movement_event_distance_threshold   // fixed: 0.02
    || window_distance >= movement_window_distance_threshold;              // slider-controlled
```

This means even at "Low" sensitivity (`movementWindowDistanceThreshold = 0.2`), any single frame where the cursor travels ≥ 2% of normalised screen width still triggers a zoom. In practice the per-frame branch can dominate, making the "Low" end of the slider much weaker than users expect.

To make the slider fully effective, both thresholds should scale together. You could either:
- Expose `movementEventDistanceThreshold` as part of the sensitivity curve, or
- Derive `movementEventDistanceThreshold` proportionally from the `movementWindowDistanceThreshold` value rather than keeping it at its hardcoded default.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2119 to +2121
let settings = GeneralSettingsStore::get(&app)
.unwrap_or(None)
.unwrap_or_default();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Idiomatic error handling with ok().flatten()

unwrap_or(None) on a Result<Option<T>> is non-idiomatic and silently discards the error. The codebase already uses a cleaner pattern elsewhere (e.g. recording.rs:598 uses .ok().flatten()):

Suggested change
let settings = GeneralSettingsStore::get(&app)
.unwrap_or(None)
.unwrap_or_default();
let settings = GeneralSettingsStore::get(&app)
.ok()
.flatten()
.unwrap_or_default();

This makes the intent explicit — convert Err to None, then fall back to defaults — and is consistent with the rest of the file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 2119-2121

Comment:
**Idiomatic error handling with `ok().flatten()`**

`unwrap_or(None)` on a `Result<Option<T>>` is non-idiomatic and silently discards the error. The codebase already uses a cleaner pattern elsewhere (e.g. `recording.rs:598` uses `.ok().flatten()`):

```suggestion
    let settings = GeneralSettingsStore::get(&app)
        .ok()
        .flatten()
        .unwrap_or_default();
```

This makes the intent explicit — convert `Err` to `None`, then fall back to defaults — and is consistent with the rest of the file.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant